iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
自我挑戰組

Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記系列 第 6

Day 6 - POM 基礎:把登入包裝成 LoginPage,重構 Day 2 的測試

  • 分享至 

  • xImage
  •  

在 Day 5 的測試程式中我們用 beforeEach 把重複的登入邏輯搬出測試本身,但如果我們開啟了另一個新的測試檔案,同樣的登入程式碼是不是又要再複製貼上一次?

為了解決這類跨檔案重複使用的痛點,讓我們來學習 Playwright 自動化測試中最經典的架構之一:POM(Page Object Model),並帶大家親手把登入流程封裝成 LoginPage

beforeEach 與 POM 的差別

  • beforeEach 負責「何時執行」:它是 Playwright Test 提供的 hook,用來讓同一個檔案裡的每支測試在執行前自動跑一次前置動作。不過它的作用範圍僅限於當前檔案,沒辦法跨檔案共用。
  • POM 負責「如何管理與共用程式碼」:它是一種設計模式,將頁面上「有哪些元素」以及「可以做哪些操作」封裝成一個獨立的模組。如此一來,「登入」這件事就變成了一個可以被任何測試檔案 import 重複利用的工具。

這兩者並非二選一,而是可以互相搭配。今天的目標是先帶大家實作出這個「可以跨檔案 import 的登入模組」。至於要怎麼讓每支測試自動取得這個模組並保持登入狀態,我們會在後續介紹 Fixture 時再深入探討。

POM 是什麼?

簡單來說,POM(Page Object Model)就是:把「頁面上有哪些元素」跟「在頁面上可以做什麼事」包裝成一個 Class,而測試檔案只負責呼叫這些方法並寫下斷言。

以登入頁面為例:

  • 有哪些元素:帳號輸入框、密碼輸入框、登入按鈕。
  • 可以做什麼事:開啟登入頁、填寫帳號密碼並送出。

當我們把這些細節封裝成一個 LoginPage Class 之後,我們在測試腳本裡就只會看到類似 loginPage.login('demo', 'demo') 這樣簡潔的語法。

在測試專案中導入 POM 架構,主要有以下三個好處:

  1. 選擇器(Locator)集中管理
    如果在未來的某一天,PM 決定把登入按鈕的文案從 Sign in 改成 Log in。只要有了 POM,你只需要去 LoginPage 裡修改那一行的 locator,所有用到登入的幾十支測試就能一口氣全部修好。這是 POM 在後續維護上最直接的價值。

  2. 測試腳本讀起來更貼近真實使用者行為
    loginPage.login('demo', 'demo') 讓人一看就知道是在執行登入;反觀 page.getByRole('textbox', { name: 'Username' }).fill('demo') 則是在描述 DOM 節點的操作細節。測試腳本應該讓人能很容易看懂「這支測試要驗證什麼」,而不是「這支測試點擊了哪些網頁元素」。

  3. 跨檔案的邏輯重複使用
    透過封裝,登入的邏輯只會存在於專案中的一個地方,未來再有新的測試檔案需要登入,直接 import 進來用就可以了。

動手寫 LoginPage

playwright-tests 底下新增一個 pages 資料夾,跟 tests 同一層:

playwright-tests/
├── pages/
│   └── LoginPage.ts        ← 今天新增
├── tests/
│   ├── login.spec.ts
│   └── dashboard.spec.ts
└── playwright.config.ts

LoginPage.ts 的內容:

import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly signInButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.getByRole('textbox', { name: 'Username' });
    this.passwordInput = page.getByRole('textbox', { name: 'Password' });
    this.signInButton = page.getByRole('button', { name: 'Sign in' });
  }

  async goto() {
    await this.page.goto('/');
  }

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.signInButton.click();
  }
}

這段程式碼有幾個重點:

  • constructor 接收 page:每支測試都會拿到一個由 Playwright 建立的全新分頁(page)。Page Object 本身不負責開啟瀏覽器,而是接收測試傳入的 page 來操作。這也是為什麼每支測試都需要 new LoginPage(page)
  • Locator 存成 readonly 屬性:將屬性設為 readonly 能防止在後續操作中意外覆寫 Locator,確保測試碼的穩定性。此外,這裡常會有個疑問:在 new LoginPage(page) 時頁面根本還沒打開,這時先呼叫 getByRole(...) 不會報錯嗎?
    答案是不會。如同我們在 Day 5 提到的,Locator 具備惰性求值的特性,它只是一張「尋人啟事」。建立時並不會去接觸頁面,只有在執行 .fill().click() 時才會去尋找元素,所以放在 constructor 裡預先建好完全沒問題。
  • 方法名稱貼近使用者行為:例如 login(username, password),我們用它來描述「登入」這個行為,而不是「填兩個欄位然後點擊按鈕」。這讓測試碼讀起來更直觀。
  • goto() 的路徑設為 /:為了避免將完整網址寫死在各個測試檔中,我們可以統一在 playwright.config.ts 設定 baseURL
export default defineConfig({
  use: {
    baseURL: 'http://localhost:8000',
  },
});

設定好之後,所有的 page.goto() 只需要給相對路徑,Playwright 就會自動幫我們補上前面的網址。未來如果需要切換測試環境,也只要改這一個地方就好。

改寫前後對比

先看 Day 2 的原始版本:

import { test, expect } from '@playwright/test';

test('測試標題', async ({ page }) => {
  // 1. 開啟登入頁
  await page.goto('http://localhost:8000/');

  // 2. 填入帳號密碼
  await page.getByRole('textbox', { name: 'Username' }).fill('demo');
  await page.getByRole('textbox', { name: 'Password' }).fill('demo');

  // 3. 點擊登入
  await page.getByRole('button', { name: 'Sign in' }).click();

  // 4. 驗證登入成功
  await expect(
    page.getByRole('heading', {
      name: 'Welcome to the react-admin e-commerce demo',
    })
  ).toBeVisible();
});

LoginPage 改寫之後:

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('使用者可以登入後台', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login('demo', 'demo');

  await expect(
    page.getByRole('heading', {
      name: 'Welcome to the react-admin e-commerce demo',
    })
  ).toBeVisible();
});

原本的版本我們需要依賴註解才能知道每一段程式碼的意圖,而導入 POM 後,因為我們將細節的 DOM 操作封裝起來,並把操作描述成有意義的使用者行為,即使沒有註解,也能一眼就知道測試流程:進入登入頁、使用 demo/demo 登入、確認畫面出現歡迎訊息。

再做一個 ProductsPage

接下來我們進行第二個練習,來寫商品列表的測試,並順便建立 ProductsPage,看看不同頁面的 POM 長什麼樣子。

// pages/ProductsPage.ts
import { type Page, type Locator } from '@playwright/test';

export class ProductsPage {
  readonly page: Page;
  readonly menuItem: Locator;
  readonly searchInput: Locator;
  readonly createButton: Locator;
  readonly resultCount: Locator;

  constructor(page: Page) {
    this.page = page;
    this.menuItem = page.getByRole('menuitem', { name: 'Posters' });
    this.searchInput = page.getByRole('textbox', { name: 'Search' });
    this.createButton = page.getByRole('link', { name: 'Create' });
    this.resultCount = page.getByText(/^\d+-\d+ of \d+$/);
  }

  async goto() {
    await this.menuItem.click();
  }

  async search(keyword: string) {
    await this.searchInput.fill(keyword);
  }

  productCard(reference: string): Locator {
    return this.page.getByRole('link', {
      name: new RegExp(`^${reference}\\b`),
    });
  }
}

有兩個地方跟 LoginPage 不太一樣。

goto() 透過點擊側邊選單切換到商品列表頁。 雖然可以直接 page.goto('/#/products'),但點擊選單更貼近真實使用者的操作路徑。Page Object 的方法名稱可以固定都叫 goto,至於底下的實作方式則可以依據該頁面的實際情況來決定。

productCard() 方法回傳 locator。 因為要抓哪一張卡片得看參數,沒辦法在 constructor 就決定,所以回傳 Locator,而不是 Promise,這裡它只是提供找到畫面上元素的方法,但還沒去頁面上找,所以呼叫的時候不用 await。這是 Page Object 裡很常見的一種寫法,測試拿到之後可以自己決定要對這個元素做操作或是進行測試斷言。

resultCount 抓的是列表下方的分頁文字。

接下來直接在測試中使用 ProductsPage

// scratch.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ProductsPage } from '../pages/ProductsPage';

test('搜尋商品後,列表只顯示符合的結果', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const productsPage = new ProductsPage(page);

  await loginPage.goto();
  await loginPage.login('demo', 'demo');

  await productsPage.goto();
  const totalBeforeSearch = await productsPage.resultCount.innerText();

  await productsPage.search('beach');

  await expect(productsPage.resultCount).not.toHaveText(totalBeforeSearch);
  await expect(productsPage.productCard('Beach Gazebo')).toBeVisible();
});

這裡有一個小提醒:用「搜尋前的筆數」作為基準,來斷言搜尋後的筆數不一樣,而不是把數字寫死(如 toHaveText('1-3 of 3'))。因為測試資料每次都會重新產生,寫死數字容易導致測試失敗。這種寫法也能利用 expect 自動重試的機制,達到「等待搜尋結果」的效果,比使用 waitForTimeout 更穩定。

最後,我們可以執行看看測試結果:

npx playwright test scratch.spec.ts

執行結果會顯示測試成功:

Running 3 tests using 3 workers
  3 passed (17.8s)

To open last HTML report run:

  npx playwright show-report

Page Object 該不該放斷言?

前面在寫 LoginPage 時,你可能會想加上斷言,例如expect(page.getByRole('button', { name: 'Sign in' })).toBeDisabled(),這樣測試裡面的程式不就更簡潔了嗎?
但其實並不建議這麼做,原因如下:

  • 提高複用性:Page Object 裡面的 method 是為了讓不同的測試都能重複使用。但每支測試想要驗證的東西可能都不一樣,硬把斷言包進去反而會限制了複用的彈性。
  • 職責分離:Page Object 的職責是描述「這個頁面能做什麼」,斷言則是描述「這次測試期望什麼」。職責分明,測試失敗時才容易一眼看出是哪裡出錯。

將 locator 開放給測試使用是完全沒問題的(例如前面的 productsPage.resultCount)。讓 POM 負責「找出元素」,讓測試負責「驗證結果」,這樣的界線既清楚又實用。

今日小結

今天把登入邏輯到處重複的問題用 Page Object Model 把登入頁包裝成了 LoginPage,把商品列表封裝成了 ProductsPage,讓我們的測試檔案從原本繁瑣的「描述 DOM 操作」進化成了「描述有意義的使用者行為」。

關於 POM,最核心的原則其實是:測試檔案不應該出現 locator。只要你在測試腳本裡看到出現了 getByRole(...) 之類的語法,就是一個可以考慮往 Page Object 搬移的訊號。

下一篇是第一個緩衝日,我們會把 Day 2 到今天學的東西整合起來,用 POM 架構實際跑一次完整的後台 CRUD 流程:搜尋商品列表、建立一筆新商品、確認它真的出現在列表裡。

思考一下

回頭看看我們今天改寫後的測試腳本,雖然登入步驟已經被簡化,但每支測試一開頭還是得出現這三行呼叫:

const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');

這代表如果有幾十支測試,這三行不僅會不斷重複出現,更麻煩的是每支測試都得從頭重跑一次真實的登入流程。有沒有辦法可以讓測試「直接拿到一個已經登入好的頁面」,連這三行程式碼都可以省下來呢?

當然有,我們後續會學習的 Fixture 機制可以解決這個問題!


上一篇
Day 5 - Locator 基礎入門:選取元素的策略與實戰
下一篇
Day 7 - 緩衝日①:後台 CRUD 流程實戰演練
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言